Custom Component Editors
By Peter Morris - pete@stuckindoors.com
This article originally appeared in Delphi Developer - Copyright Pinnacle Publishing, Inc. All rights reserved. Reprinted with permission.
As soon as we start to write advanced property types for our components, life becomes a little more complicated. Although the object inspector built into Delphi is able to recognise most property types, it is impossible for it to be able to deal with every possible custom type we may write into our components. Sometimes the object inspector is able to deal with our custom types, but editing such a complex arrangement of properties in the object inspector is simply not intuitive enough. It is at this point we may be required to write property / component editors. Delphi has many predefined editors already, these editors are in the DsgnIntf.pas file in the $(Delphi)\Source\ToolsAPI directory. You will need to list this unit in the uses clause of any component editor / property editor you may write, it is also a good idea to keep this file open for reference when writing your own editors.
Coding Standards
To start off with I will cover some coding standards that are used when writing component or property editors. There are only a few, but it would be a good idea to keep to these standards when writing your own editors as it makes it easier for other people to understand your work.
Property editors are used by the IDE to allow special editing of individual properties within a component. Some editors are very simple, some are much more complicated. Delphi already has a number of standard property editors, some of these are:
TIntegerProperty. Used for inputting integers.
TCharProperty. Used for inputting a single character.
TEnumProperty. Used for selected an individual element in an enumerated type (alTop, alClient etc). TBoolProperty. Used for selecting "True" or "False" for Boolean properties.
TFloatProperty. Used for inputting floating point numbers (Variable type Float / Extended etc. The "Real" type should not be used for component properties).
TStringProperty. Used for inputting strings up to a maximum of 255 characters.
TSetProperty. Used for including / excluding individual elements of a Set property. Each element is displayed as a Boolean sub-property. Setting the value to "True" includes the element, setting it to "False" excludes it.
TClassProperty. This is the base class to descend from when you want to create a custom editor to be invoked for properties of a certain class (when you have a class as a property, such as TImage.Picture).
All of these property editors descend directly or indirectly from TPropertyEditor. TPropertyEditor has many properties and methods, the most significant are.
function AllEqual: Boolean; virtual;AllEqual
function GetAttributes: TPropertyAttributes; virtual;
procedure Edit; virtual;
function GetValue: string; virtual;
procedure GetValues(Proc: TGetStrProc); virtual;
function TStringProperty.AllEqual: Boolean;In the above example TStringProperty compares each value (using GetStrValueAt) with the value of the first component in the list (using GetStrValue, GetStrValueAt(0) would have done the same). The size of the list is determined by using PropCount, this returns the total amount of components selected.
var
I: Integer;
V: string;
begin
Result := False;
if PropCount > 1 then
begin
V := GetStrValue;
for I := 1 to PropCount - 1 do
if GetStrValueAt(I) <> V then Exit;
end;
Result := True;
end;
GetAttributes
GetAttributes is called by the IDE when it needs to
gather information about the property editor. The object inspector displays an
appropriate editor based on the information supplied. The result of
GetAttributes (TPropertyAttributes) is a set, so it may contain a combination of
the following values (this is not a complete list)
paDialogEdit
Tells the object inspector to show a […] button after the property name, when the user clicks this button the Edit method is triggered.paSubProperties
Tells the object inspector to show a [+] expand button before the property name, clicking this button will show an expanded list of sub properties (usually the published properties of a class property).paValueList
The object inspector will show a combobox with a list of values, this list is determined by the IDE by calling the GetValues method.
NOTE: The GetValues method, not the GetValue method which is completely differentpaSortList
If combined with paValueList, the values displayed will be sorted alphabetically.paMultiSelect
This specifies to the IDE that the property is allowed to be displayed when multiple components are selected. This item is not present for editors such a TClassProperty.paAutoUpdate
Causes the SetValue method to be called each time the value is altered within the object inspector, rather than waiting for the user to press or edit another property. This is used for "Caption" and "Text" properties, to give a live representation of the value the user is entering.paReadOnly
If this element is included the value in the object inspector is read-only. This is typically used in conjunction with paDialog. GetValue would be overridden to return a descriptive representation of the property.
GetValue
This method is called when the object inspector needs to
know how to display the property as a string. This is typically used when
[paDialog, paReadOnly] are specified within the result of GetAttributes.
GetValues
This method is called when the object inspector needs to
retrieve a list of values to display when paValueList is specified within the
result of GetAttributes. GetValues passes a parameter called "Proc" which is of
type TGetStrProc. GetStrProc is declared as TGetStrProc = procedure(const S:
string) of object;
The IDE expects "Proc" to be called once for every value that should be displayed in the object inspector for this property.
procedure THintProperty.GetValues(Proc: TGetStrProc);The following example shows how to provide a list of default values for the "Hint" property of all components, whilst still allowing the user to enter a value not in the list.
begin
Proc('First item to display');
Proc('Second item to display');
end;
typeFirst GetAttributes is overridden, and [paValueList, paSortList] are included in the result. Next GetValues is overridden and three values are added to the drop down list by calling the "Proc" procedure.
THintProperty = class(TStringProperty)
public
function GetAttributes: TPropertyAttributes; override;
procedure GetValues(Proc: TGetStrProc); override;
end;procedure Register;
implementation
procedure Register;
begin
RegisterPropertyEditor(TypeInfo(String), nil, 'Hint', THintProperty);
end;{ THintProperty }
function THintProperty.GetAttributes: TPropertyAttributes;
begin
Result := inherited GetAttributes + [paValueList, paSortList];
end;procedure THintProperty.GetValues(Proc: TGetStrProc);
begin
Proc('This is a required entry');
Proc('Press F1 for more information');
Proc('This value is read-only');
end;
Registering property editors
Finally the property editor is registered using RegisterPropertyEditor. RegisterPropertyEditor takes four parameters:
PropertyType: PTypeInfo
Requires a pointer to a TTypeInfo record.
This sounds much more complicated than it really is, all we need to do is add
TypInfo to our uses clause, and use the TypeInfo function to retrieve the
pointer for us. TypeInfo(SomeVariableType)
ComponentClass: TClass
This is the base class that this editor
should apply to. The editor will apply to this class and any classes that
descend from it. If nil is specified, this editor will apply to any class.
const PropertyName: string
If this editor should only apply to a
specific property then the name of the property should be specified here. If the
editor should apply to any property of the type specified in PropertyType this
value should be ''.
EditorClass: TPropertyEditorClass
This is the class that has been
created to deal with the property. In the above example the class is
THintProperty.
Using RegisterPropertyEditor Incorrectly
It is important when using RegisterPropertyEditor that you supply the correct information. Supplying the incorrect information could mean either that your editor affects incorrect properties (eg All string properties) or incorrect components.
At the other extreme, setting the parameters incorrectly could mean that only a specific property in a specific component (and descendants) is associated with your editor. This does not seem like much of a problem at first, but descendant components may wish to implement additional properties of the same type. As these properties will obviously have a different name they will not have the correct property editor assigned to them.
An example of badly registered editor already exists within the VCL. The standard editor for TCollection was registered for all classes descended from TComponent. The problem is that the lowest class capable of being displayed in the object inspector is TPersistent (the class that TComponent descends from).
If a component has a property of type TPersistent (which by default exposes its sub-properties in an expandable list), and one of its properties is of type TCollection, the result is a […] button in the object inspector that does nothing when clicked (as we saw in part two of this article series).
The solution to this problem seems quite simple. Rather than our sub-property being descended from TPersistent we could descend it from TComponent instead. However, the default behaviour for a property of type TComponent (As determined by the property editor TComponentProperty editor) is to show a list of other components, rather than the sub-properties of an embedded component.
The actual solution really is simple, but only if you know how to write a property editor.
Step 1:
typeShould be changed to read
TExpandingRecord = class(TPersistent)
typeStep 2: Create a property editor like so
TExpandingRecord = class(TComponent)
typeStep 3: Remove the RegisterComponents call from the component unit, and register it within the editor unit instead. This way we can ensure the component will not be registered without the component.
TExpandingRecordProperty = class(TClassProperty)
public
function GetAttributes : TPropertyAttributes; override;
end;procedure Register;
implementation
procedure Register;
begin
RegisterComponents('Article', [TExpandingComponent]);
RegisterPropertyEditor(TypeInfo(TExpandingRecord), nil, '', TExpandingRecordProperty);
end;{ TExpandingRecordProperty }
function TExpandingRecordProperty.GetAttributes: TPropertyAttributes;
begin
Result := [paReadOnly, paSubProperties];
end;
Now our property of type TExpandingRecord will show as an expanding property (due to us returning paSubProperties from GetAttributes), and the default editor for TCollection will work as the owner of the TCollection property is a TComponent.
Dialog property editors
Most of the time, when creating a custom property editor, the purpose is to provide a graphical means of interacting with the property.
This first example is a very simple way of allowing the user to enter multiple lines in the "Caption" property of a TLabel. Although this example is not very complicated, it demonstrates how to include a form within your editor.
Step 1:
Select File, New Application from the main menu. This will
create a form, name the form "fmLabelEdit", add a TMemo to the form named
memCaption. Add two buttons, "OK" and "Cancel" with the ModalResult properties
set to mrOK and mrCancel respectively.
Step 2:
Add DsgnIntf and TypInfo to your uses clause.
Step 3:
Add the following property editor code to your unit.
TCaptionProperty = class(TStringProperty)And register the property editor like so
public
function GetAttributes: TPropertyAttributes; override;
procedure Edit; override;
end;
procedure Register;Step 4:implementation
{$R *.DFM}procedure Register;
begin
RegisterPropertyEditor(TypeInfo(TCaption), TLabel, 'Caption', TCaptionProperty);
end;
function TCaptionProperty.GetAttributes: TPropertyAttributes;Step 5:
begin
Result := inherited GetAttributes + [paDialog];
end;
procedure TCaptionProperty.Edit;Step 6:
var
I: Integer;
begin
with TfmLabelEdit.Create(Application) do
try
memCaption.Lines.Text := GetStrValue;
ShowModal;{If the ModalResult of the form is mrOK, we need to set the "Caption" property of each TLabel.}
if ModalResult = mrOK then
for I:=0 to PropCount-1 do
TLabel(GetComponent(I)).Caption := memCaption.Lines.Text;
finally
Free;
end;
end;
Advanced property editors
Anyone who has ever used TActionList or TDataSet (TTable / TQuery) will have experience of the following example, possibly without even realising.
The ActionList editor is obviously a custom editor as it allows grouping of actions, whereas the FieldsEditor of TDataSet may at first seem like a standard editor, but upon closer inspection has a popup menu with items such as "Add fields". However, the most remarkable feature of both of these editors is not that they are custom dialog editors (similar to the one we covered earlier), but the fact that the items they create are included in the main class declaration of the current unit.
typeThe benefit of this is that the IDE is made aware of these items, therefore allowing them to be selected from a list of objects whenever the property of a component requires them.
TForm1 = class(TForm)
ActionList1: TActionList;
Action1: TAction;
Action2: TAction;
private
{ Private declarations }
public
{ Public declarations }
end;
In the above illustration, two actions are added to a TActionList, clicking the "Action" property of Button1 shows a list consisting of the actions added. The two actions are also added to the Form's class declaration, and can therefore be referred to by name (Action1, Action2).
The trick here lies entirely in the property editor and not within the component. When a property editor is triggered (ie the Edit method is called) the Designer property contains a valid reference to an IFormDesigner (TFormDesigner in Delphi 4). Many of the functions of this interface are not within the scope of this article, if you wish to learn more about the capabilities of this interface I would recommend a book called Delphi Developer's Handbook by Marco Cantu.
Some of the methods include
function MethodExists(const Name: string): Boolean;Some of the above calls are fairly elementary, MethodExists for example will return True or False depending on whether or not a method name already exists within the form of the current unit (FormCreate, Button1Click etc). ShowMethod will move the cursor to the named method, and RenameMethod will change the name of a method.
procedure RenameMethod(const CurName, NewName: string);
procedure SelectComponent(Instance: TPersistent);
procedure ShowMethod(const Name: string);
function GetComponent(const Name: string): TComponent;
function CreateComponent(ComponentClass: TComponentClass; Parent: TComponent; Left, Top, Width, Height: Integer): TComponent;
The two methods that are of interest to use at this point are:
CreateComponent
Given a component class, a parent to hold the
component, and position / dimensions, the designer will create an instance of
the class as if the developer had selected it from the component palette and
added it to the form themself.
Modified
Informs the designer that something has been altered (a
property etc). This alters the state of the unit so that the IDE knows it should
be saved before closing (it also enables the save button in the IDE).
When adding items to our array all we need to do is to get TMyProperty.Designer to create a component on our behalf. This component will then be added to the form and any property that refers to a class of this type will automatically be aware of it. In the case of TActionList and TDataSet the components that are added to the form are not visible at design-time, the owner component acts as a kind of "manager" for the components.
During design-time you wont see a TAction or a TField component on the component palette which would possibly make you suspect they are not registered, yet the IDE is still able to create instances of these components (and they are also not visible). The answer is not that they aren't registered, this behaviour is a result of "how" the component is registered.
Whereas RegisterComponents will add your components to the component palette, the RegisterNoIcon method will register your component without adding it to the component palette, registering in this way also tells the IDE that the component should not be displayed during design-time.
In the following example we will create a component called a TWavSound (a
additional component called TWavButton is included in the source code that
accompanies this article as an example). TWavSound will simply hold data from a
WAV file, and play the sound on demand. Although it would be simple for us to
drop one TWavSound onto our form for each WAV sound we require, our form could
soon start to become unmanageable, therefore we will also create a manager class
called TWavList.
Every technique used in the source code to these components was covered in part two of this series of articles so the source code will not be covered in any great level of detail. However, I will show the class declarations of these components just to give you an idea of how they are structured.
Note: At the bottom of the unit, within the initialization section of the unit you may notice the following code:
initialization
RegisterClass(TWavSound);
The reason is that RegisterNoIcon doesn't seem to do a complete job. Although it allows us to create instances of the registered component from our property editor something seems to go wrong when a project is re-loaded containing these components. A "Class not registered" message box is displayed and the project is corrupted. Additionally registering the class in this way seems to fix the problem
TWavSound
typeFWavData
PWavData = ^TWavData;
TWavData = packed record
Size: Longint;
Data: array[0..0] of byte;
end;TWavSound = class(TComponent)
private
FWavData: PWavData;
FWav: TWav;
procedure ReadWavData(Stream: TStream);
procedure WriteWavData(Stream: TStream);
protected
procedure DefineProperties(Filer: TFiler); override;
public
destructor Destroy; override;
procedure Clear;
procedure LoadFromFile(const Filename: TFilename);
procedure LoadFromStream(Stream: TStream);
procedure Play;
published
end;
Clear
Will free the memory holding FWavData.
Play
Will use the sndPlaySound API call in MMSystem.pas to play
the data in FWavData.Data.
ReadWavData and WriteWavData
Will be used internally by the
IDE when it needs to read / write the data stored within FWavData.
DefineProperties
Will specify a "hidden" property called WavData,
and tell the IDE that ReadWavData and WriteWavData should be used for streaming
the data.
FWav
Is set internally by the TWav class when TWav.WavSound is set
to our component. The reason is that this collection item will need to be freed
when our TWavSound component is freed, in order to stop it from pointing to an
invalid object.
TWavSound
typeSetWavSound
TWav = class(TCollectionItem)
private
FWavSound: TWavSound;
procedure SetWavSound(const Value: TWavSound);
protected
public
procedure Play;
published
property WavSound: TWavSound read FWavSound write SetWavSound;
end;
TWavs
Is a standard implementation of TCollection so will not be
covered in this article. (See part 2 of this series)
TWavList
TWavList is simply a component that publishes a TWavs
property to allow us to edit the list of wavs at design-time.
TWavsProperty
TWavsProperty is the property editor that has been
designed to handle this class. Although a standard TCollection editor would be
sufficed (to a point) I decided to create a new editor in order to allow the
playing / clearing of WAVs at design-time.
First I created a new unit with a form in. I added a few TSpeedButtons and a TListBox to list the items in.
Additionally, I added the following items to the Form's class declaration
FWavs: TWavs;FWavs
FComponent: TComponent;
TheDesigner: IFormDesigner;
FComponent
Will hold a reference to the component that owns the
collection. As our form will not be shown modally we will need to close our form
if this component is destroyed (using the Notification method of our form).
TheDesigner
Will hold a reference to the current Designer object
passed to our property editor. This will be used to call CreateComponent, and to
select our hidden TWavSound into the object inspector whenever an item is
selected in our listbox.
The actually property editor is a very simple one.
typeThe only real method worth mentioning here is the Edit method. The implementation of which is
TWavsProperty = class(TClassProperty)
public
function GetAttributes: TPropertyAttributes; override;
function GetDisplayName: string;
procedure Edit; override;
end;
procedure TWavsProperty.Edit;First the editor form is created (if not already created).
begin
if fmWavsEditor = nil then
fmWavsEditor := TfmWavsEditor.Create(Application);with fmWavsEditor do
begin
TheDesigner := Self.Designer; //Don't forget SELF !!
Caption := Self.GetName;//Setup the display, and then show the form
Edit(TComponent(GetComponent(0)), TWavs(GetOrdValue));
end;
end;
"TheDesigner" of the Form is set to Self.Designer. Do not forget the "Self" here as TForm also has a Designer property which at this point will be nil.
GetComponent(0) is used to retrieve the component that owns the property. FreeNotification is called for this component to ensure that our form is notified if the component is destroyed (so that we can close our form).
GetOrdValue is used to retrieve the class object (the "Wavs" property") that is to be edited, the result is typecast as TWavs.
The Edit method that is called is part of TfmWavsEditor, it is a method I added which simply clears the listbox and populates the items with the names of the FWavs entries. It then shows the form.
Note: Later versions of Delphi return TPersistent from the GetComponent function, therefore the result must be typecast to TComponent.
Talking to IFormDesigner
The main two parts of this editor (except for clearing the WAV and playing the WAV) are the parts where "TheDesigner" is interacted with.
The first part to mention should be the part where the "New" button is clicked, a new item is added to the collection, a new TWavSound is added to our form's class declaration, and finally the TWavSound is selected into the object inspector.
procedure TfmWavsEditor.sbNewClick(Sender: TObject);The second part to mention is where the correct TWavSound is selected into the object inspector when an item is clicked in the listbox.
var
Wav: TWav;
WavSound: TWavSound;
begin
//Add an item to the collection
Wav := FWavs.Add;//Ask TheDesigner to create a new TWavSound component for us
WavSound := TWavSound(TheDesigner.CreateComponent(TWavSound,
nil, 0, 0, 0, 0));//Set the Wav (CollectionItem) to point to our new TWavSound component
Wav.WavSound := WavSound;//Select our new TSoundComponent into the object inspector
//so that it may be renamed if so desired
TheDesigner.SelectComponent(WavSound);//Internally refresh the items in the listbox
RefreshList;
lbItems.ItemIndex := FWavs.Count-1;//Tell the IDE that something has changed
TheDesigner.Modified;
end;
procedure TfmWavsEditor.lbItemsClick(Sender: TObject);
begin
with lbItems do
if ItemIndex >=0 then
TheDesigner.SelectComponent(FWavs[ItemIndex].WavSound);
end;
Avoiding Access Violations
Finally we need to ensure that we are not left referencing an object that is no longer valid. This is quite simply achieved by following the following two steps
procedure TfmWavsEditor.Edit(AComponent: TComponent; AWavs: TWavs);What to do when a component is destroyed:
begin
//First we need to remove notification for the current component
if FComponent <> nil then
FComponent.RemoveFreeNotification(Self);//Now we need to add notification for the current component
AComponent.FreeNotification(Self);
FComponent := AComponent;FWavs := AWavs;
lbItems.ItemIndex := -1;
RefreshList;Show;
end;
procedure TfmWavsEditor.Notification(AComponent: TComponent; Operation: TOperation);Summary
begin
inherited;
if Operation = opRemove then
begin
//If the owner component is destroyed
//we should close our form
if (AComponent = FComponent) then
Close
else
//If the component that is destroyed
//we refresh our list just incase it affects our component
if (AComponent is TWavSound) then
RefreshList;
end;
end;
In this article we covered how to write a component editor, we then moved on to creating simple property editors, finally we covered more advanced property editors (including minimal use of the IFormDesigner interface). All of the demonstrated techniques in this article (and more) have been used in my DIB (device independent bitmap) components.
These components are available for free download from http://www.StuckIndoors.com/dib and are open-source (so any development contributions would be greatly appreciated).
(c) Peter Morris